/*
 * Copyright 2009-2010 Belmont Software Services
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.belmont.backup;

import java.io.*;
import java.util.*;
import java.sql.*;
import org.json.*;

public class FileBackupServer implements IBackupServer, IBackupConstants, Runnable {
    static final boolean DEBUG = false;
    File root;
    File dbroot;
    FileStorage storage;
    String workingPath;
    int filesTransferred;
    int filesToTransfer;
    Config config = new Config();
    Hashtable<String, FileDatabase> databases = new Hashtable<String, FileDatabase>();
    static final long IDLE_DB_TIMEOUT = 30 * 1000; // in seconds
    Thread dbReaper;

    //Hashtable<String, UserSession> sessions = new Hashtable<String, UserSession>();

    public FileBackupServer(File root) {
	this.root = root;
	this.dbroot = new File(root,"databases");
	root.mkdirs();
	dbroot.mkdirs();
	try {
	    this.storage = FileStorage.getStorage(new File(root, STORE_DIR).getCanonicalPath());
	    dbReaper = new Thread(this);
	    dbReaper.start();
	} catch (IOException ex) {
	    Utils.log(LOG_ERROR, "Error creating file storage", ex);
	}
    }

    synchronized FileDatabase getDatabase(String session) throws IOException {
	FileDatabase db = databases.get(session);

	if (db == null) {
	    db = new FileDatabase();
	    db.init(new File(dbroot, session+".db").getAbsolutePath());
	    databases.put(session, db);
	}

	return db;
    }

    public void run() {
	while (true) {
	    try {
		Thread.sleep(IDLE_DB_TIMEOUT);
	    } catch (InterruptedException ex) {
		return;
	    }
	    synchronized (this) {
		long now = System.currentTimeMillis();
		Enumeration<String> dbs = databases.keys();
		while (dbs.hasMoreElements()) {
		    String k = dbs.nextElement();
		    FileDatabase db = databases.get(k);

		    if (db == null) {
			continue;
		    }
		    long access = now - db.getLastAccess();
		    if (access > IDLE_DB_TIMEOUT) {
			db.close();
			databases.remove(k);
		    }
		}
	    }
	}
    }

    /**
     * Create a backup record with the session as id and the given path. Mark status as pending.
     */
    public JSONObject startBackup(String session, String path) throws IOException, JSONException {
	Utils.log(LOG_INFO, "starting backup "+session+" "+path);
	FileDatabase db = getDatabase(session);
	long tm = System.currentTimeMillis();
	String backupid = "backup."+session+"."+tm;

	try {
	    db.addBackup(backupid, session, new java.sql.Date(tm),
			 null, path, "PENDING", null);
	} catch (SQLException ex) {
	    Utils.log(LOG_ERROR, "Error in startBackup", ex);
	    throw new IOException("Database exception: "+ex.toString());
	}

	JSONObject obj = new JSONObject();
	obj.put("session", session);
	obj.put("path", path);
	obj.put("backupid", backupid);

	return obj;
    }

    public synchronized void stop() {
	Utils.log(LOG_INFO, "Stopping backup server");
	dbReaper.interrupt();
	Enumeration<FileDatabase> dbs = databases.elements();
	while (dbs.hasMoreElements()) {
	    dbs.nextElement().close();
	}
	databases = new Hashtable<String,FileDatabase>();
    }

    /**
     * For each file request in fileRequest check whether the file
     * already exists in storage. Return an array of files that were
     * not found. 
     */
    public JSONObject checkFiles(String session, String backupid, String path, JSONObject fileRequest) throws IOException, JSONException {
	JSONArray requests = fileRequest.optJSONArray("files");
	JSONObject reply = new JSONObject();
	int l = (requests == null) ? 0 : requests.length();

	long size = 0;
	FileDatabase db = getDatabase(session);

	try {
	    //db.beginTransaction();
	    for (int i=0; i < l; i++) {
		JSONObject request = requests.optJSONObject(i);
		
		if (request == null) {
		    continue;
		}
		String ck = request.optString("checksum");
		String fpath = request.optString("path");
		long fsize = request.optLong("size");
		long modified = request.optLong("modified");
		
		// XXX: calculate offset
		if (ck != null && storage.get(ck) == null) {
		    FileRequest r = new FileRequest(fpath, ck, fsize, 0);
		    size += fsize;
		    reply.append("requests", r);
		    //System.out.println("Adding request "+fpath);
		}
		db.addBackupEntry(backupid, ck, fpath, fsize, new java.sql.Date(modified));
	    }
	    //db.commitTransaction();
	} catch (SQLException ex) {
	    Utils.log(LOG_ERROR, "Database error", ex);
	}

	reply.put("size", size);

	return reply;
    }


    /**
     * Go through all the databases and identify all the file checksums
     * that are no longer used and then delete them from file
     * storage. The way this is done is by iterating over all the
     * client databases identifying all the orphaned checksums in each
     * database (which is just a simple sql query). These checksums
     * are added to the global server database. As each in-use checksum is
     * identified in the other databases we check that it's not on the
     * gc list in the global database. If it is we mark the one in the
     * global database as in-use. If this checksum is found in another
     * database to not be in-use then it's not added to the global
     * database if it is already there. After all the databases have
     * examined we then collect all the checksums in the global
     * database that are not marked in-use and we delete them from
     * file storage. We use size > 0 for the checksum table to
     * indicate in-use (only in the global server database, for other
     * databases it really means the size of the file).
     */
    public synchronized void garbageCollect() throws IOException, SQLException {
	// XXX: need a way to lock the server and all the databases
	// from modification.

	// XXX: add logging to this method

	Utils.log(LOG_INFO, "Starting garbage collection");

	FileDatabase serverDB = getServerDB();

	// clear any checksums in the database first
	serverDB.deleteChecksums();
	String list[] = dbroot.list();
	if (list == null) {
	    return;
	}

	//
	// Iterate over all the databases to calculate checksums which
	// are not used in any backup records. For each checksum in
	// a database we add it to the server database using the size
	// column to indicate whether it is in use or not. If a
	// checksum is in use but was already in the server database
	// we change the size to 1 to indicate it is in use.
	//
	for (int i=0; i < list.length; i++) {
	    String name = list[i];

	    if ("server.db".equals(name)) {
		continue;
	    }
	    int idx = name.indexOf('.');
	    if (idx == -1) {
		continue;
	    }

	    FileDatabase db = getDatabase(name.substring(0, idx));
	    ResultSet rs = db.getChecksums();
	    try {
		while (rs.next()) {
		    int id = rs.getInt(1);
		    String ck = rs.getString(2);

		    int sid = serverDB.getChecksum(ck);

		    if (sid == -1) {
			serverDB.addChecksum(ck, (db.isInUse(id)) ? 1 : 0);
		    } else if (db.isInUse(id)) {
			serverDB.setInUse(sid);
		    }
		}
	    } finally {
		rs.close();
	    }
	}

	//
	// Now we've calculated all the checksums that weren't used in
	// any of the databases so we can now proceed to delete the
	// files represented by those checksums.
	//
	ResultSet rs = serverDB.getUnusedChecksums();
	try {
	    while (rs.next()) {
		String ck = rs.getString(1);
		SFile sf = storage.getFile(ck, false);
		Utils.log(LOG_INFO, "Deleting checksum "+ck);
		if (sf != null) {
		    storage.delete(sf);
		}
	    }
	} finally {
	    rs.close();
	}

	Utils.log(LOG_INFO, "Finished garbage collection");
    }

    public JSONObject getAvailableVersions(String session) throws IOException, JSONException {
	try {
	    FileDatabase db = getDatabase(session);
	    ResultSet rs = db.getBackupRecords();
	    JSONObject versions = new JSONObject();
	    boolean added = false;

	    while (rs.next()) {
		JSONObject v = new JSONObject();
		String path;
		String name = rs.getString(2);
		long size = db.getMaxSize(name);

		v.put("name", name);
		v.put("size", Utils.formatSize(size));
		v.put("rsize", size);
		v.put("path", path = db.getPath(rs.getInt(4)));
		v.put("start", rs.getLong(5));
		v.put("end", rs.getLong(6));
		v.put("date", rs.getLong(6));
		v.put("status", rs.getString(7));
		v.put("comment", rs.getString(8));

		versions.append(path, v);
		added = true;
	    }

	    return (added) ? versions : null;
	} catch (SQLException ex) {
	    Utils.log(LOG_ERROR, "Database exception", ex);
	    throw new IOException("database exception: "+ex.toString());
	}
    }

    void calculateVersions(File file, JSONObject versions, String path) throws JSONException {
	if (file.isDirectory()) {
	    String list[] = file.list();
	    if (list != null) {
		for (int i=0; i < list.length; i++) {
		    File f = new File(file, list[i]);
		    calculateVersions(f, versions, path+"/"+list[i]);
		}
	    }
	} else {
	    int idx = path.lastIndexOf('/');
	    if (idx != -1) {
		path = path.substring(0, idx);
	    }
	    JSONObject p = new JSONObject();

	    p.put("name", file.getName());
	    p.put("date", file.lastModified());

	    versions.append(path, p);
	}
    }

    String dbname(String host, String user) {
	return host+"-"+user+"-sqlite.db";
    }

    synchronized void addDatabase(String host, String user) throws IOException {
	String name = dbname(host, user);
	FileDatabase db = databases.get(name);

	if (db == null) {
	    Hashtable<String, String> dbprops = new Hashtable<String, String>();

	    dbprops.put("backup.database.name", new File(root, name).getAbsolutePath());
	    db = new FileDatabase();
	    db.init(dbprops);
	    databases.put(name, db);
	}
    }

    FileDatabase getServerDB() throws IOException {
	return getDatabase("server");
    }

    public long getTotalDiskUsage() {
	long total = 0;
	String list[] = dbroot.list();

	if (list == null) {
	    return total;
	}

	try {
	    for (int i=0; i < list.length; i++) {
		String name = list[i];
		if ("server.db".equals(name)) {
		    continue;
		}
		int idx = name.indexOf('.');
		if (idx == -1) {
		    continue;
		}
		FileDatabase db = getDatabase(name.substring(0, idx));
		ResultSet rs = db.getBackupRecords();
		try {
		    while (rs.next()) {
			total += db.getMaxSize(rs.getString(2));
		    }
		} finally {
		    rs.close();
		}
	    }

	    return total;
	} catch (Exception ex) {
	    Utils.log(LOG_ERROR, "Error reporting disk usage", ex);
	    return -1;
	}
    }

    public String connect(String protocolVersion, String uuid, String host,
			  String osname, String osversion, String user) throws IOException {
	try {
	    Utils.log(LOG_INFO, "Connect "+uuid+" "+host+" "+user+" "+osname+" "+osversion);
	    FileDatabase db = getDatabase(uuid);

	    db.addClient(uuid, host, user);
	    getServerDB().addClient(uuid, host, user);
	    return uuid;
	} catch (SQLException ex) {
	    Utils.log(LOG_ERROR, "Connect exception", ex);
	    throw new IOException("database error:" +ex.toString());
	}
    }

    public Vector<String[]> getClients() throws SQLException, IOException {
	Vector<String[]> r = new Vector<String[]>();
	ResultSet rs = getServerDB().getClients();
	try {
	    while (rs.next()) {
		String c[] = new String[3];
		c[0] = rs.getString(1);
		c[1] = rs.getString(2);
		c[2] = rs.getString(3);
		r.addElement(c);
	    }
	} finally {
	    rs.close();
	}

	return r;
    }

    public void refresh() throws IOException {
    }

    public Config getConfig() {
	return config;
    }

    public void setConfig(Config config) {
	this.config = config;
    }

    public void disconnect(String session) throws IOException {
    }


    public void getFile(String session, String digest, String name, long length, long offset, OutputStream out) throws IOException, InterruptedException {
	SFile sf = storage.get(digest);
	if (sf == null) {
	    throw new IOException("Error getting file for "+name);
	}

	InputStream in = sf.getInputStream(offset);
	byte buf[] = BufferPool.getInstance().get(1024);
	try {
	    int len;
	    Status status = Status.getInstance();

	    while ((len = in.read(buf)) != -1) {
		out.write(buf, 0, len);
		status.addAmountTransferred((long)len);
		Utils.checkCancel();
		//Producer.getInstance().sendNotify(this, NOTIFY_TRANSFER_PROGRESS, new Long(len));
	    }
	} finally {
	    BufferPool.getInstance().put(buf);
	    in.close();
	}
    }

    public void sendFile(String session, String digest,
			 String name, long length, long offset,
			 InputStream in) throws IOException, InterruptedException {
	SFile sf = storage.getFileCreate(digest);
	if (sf == null) {
	    Utils.log(LOG_ERROR, "Error getting file for "+name);
	    throw new IOException("Error getting file for "+name);
	}

	// out may be null if the file was already committed, errors
	// would always throw exceptions. 
	OutputStream out = sf.getOutputStream(offset);
	byte buf[] = BufferPool.getInstance().get(1024);

	try {
	    int len;
	    Status status = Status.getInstance();

	    while ((len = in.read(buf)) != -1) {
		Utils.checkCancel();
		if (out != null) {
		    out.write(buf, 0, len);
		    //Producer.getInstance().sendNotify(this, NOTIFY_TRANSFER_PROGRESS, new Long(len));
		    status.addAmountTransferred((long)len);
		}
	    }
	} finally {
	    BufferPool.getInstance().put(buf);
	    if (out != null) {
		out.close();
		try {
		    sf.commit();
		} catch (IOException ex) {
		    storage.delete(sf);
		    Utils.log(LOG_ERROR, "IO error in sendFile "+name+" "+digest, ex);
		    throw ex;
		}
	    }

	    filesTransferred++;
	}
    }

    public void cancelBackup(String session, String path) throws IOException {
	try {
	    FileDatabase db = getDatabase(session);
	    int pathid = db.getPath(path);

	    if (pathid != -1) {
		// XXX: if db is shared among clients we need to only delete the ones associated
		// with the client session.
		db.deletePendingBackups(pathid);
	    }
	} catch (SQLException ex) {
	    Utils.log(LOG_ERROR, "Database error", ex);
	}
    }

    public void deleteAllBackups(String session) throws IOException, SQLException {
	FileDatabase db = getDatabase(session);
	ResultSet rs = db.getBackupRecords();

	while (rs.next()) {
	    try {
		int id = rs.getInt(1);
		Utils.log(LOG_INFO, "Deleting backup "+rs.getString(2));
		db.deleteBackupEntries(id);
		db.deleteBackup(id);
	    } finally {
		rs.close();
	    }
	}
    }

    public void deleteBackupsForPath(String session, String path) throws IOException, SQLException {
	FileDatabase db = getDatabase(session);
	ResultSet rs = db.getBackupsForPath(path);

	while (rs.next()) {
	    try {
		int id = rs.getInt(1);
		Utils.log(LOG_INFO, "Deleting backup "+rs.getString(2));
		db.deleteBackupEntries(id);
		db.deleteBackup(id);
	    } finally {
		rs.close();
	    }
	}
    }

    public void deleteBackup(String session, String backupid) throws IOException, SQLException {
	FileDatabase db = getDatabase(session);
	int id = db.getBackupId(backupid);

	if (id == -1) {
	    throw new IOException("Unknown backup id: "+backupid);
	}
	db.deleteBackupEntries(id);
	db.deleteBackup(id);
    }

    public void commitBackup(String session, String backupid, String path) throws IOException {
	FileDatabase db = getDatabase(session);

	try {
	    if (db.getBackupId(backupid) == -1) {
		throw new IOException("Unknown backup id: "+backupid);
	    }
	    db.setBackupStatus(backupid, BACKUP_STATUS_DONE, new java.sql.Date(System.currentTimeMillis()));
	    int vlimit = config.getMaxVersions();
	    if (vlimit > 0) {
		ResultSet rs = db.getBackupsForPath(path);
		Vector<Integer> v = new Vector<Integer>();

		Utils.log(LOG_INFO, "Pruning versions for "+session+" "+path);
		try {
		    while (rs.next()) {
			if (--vlimit < 0) {
			    v.addElement(new Integer(rs.getInt(1)));
			    Utils.log(LOG_INFO, "Scheduling delete of backup "+rs.getString(2));
			}
		    }
		} finally {
		    rs.close();
		}
		Enumeration<Integer> backups = v.elements();
		while (backups.hasMoreElements()) {
		    Integer id = backups.nextElement();
		    db.deleteBackupEntries(id.intValue());
		    db.deleteBackup(id.intValue());
		}
	    }
	} catch (SQLException ex) {
	    Utils.log(LOG_ERROR, "Database error", ex);
	    throw new IOException("database exception: "+ex.toString());
	}
    }
	
    public static void main(String args[]) throws Exception {
	FileBackupServer server = new FileBackupServer(new File(args[0]));
	String session = server.connect(BACKUP_PROTOCOL_VERSION, "test.filebackupserver", "localhost",
					System.getProperty("os.name"),
					System.getProperty("os.version"),
					"BackupClientUser");
	JSONObject versions = server.getAvailableVersions(session);

	if (versions != null) {
	    int l = versions.length();
	    Iterator k = versions.keys();
	    while (k.hasNext()) {
		String path = (String)k.next();
		JSONObject version = (JSONObject)versions.opt(path);

		System.out.println("Version: "+path+" "+version.optLong("date"));
	    }
	}
	server.disconnect(session);
    }
}